Skip to content

service-automation: populate the contained-failure visibility contract — FlowRunSummary.failed fold, loop iteration through try_catch, $error.iteration / $error.item, failed= on the summary line - #15609

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-14456-contained-failure-visibility
Sep 5, 2026
Merged

service-automation: populate the contained-failure visibility contract — FlowRunSummary.failed fold, loop iteration through try_catch, $error.iteration / $error.item, failed= on the summary line#15609
os-warren merged 5 commits into
mainfrom
claude/issue-14456-contained-failure-visibility

Conversation

@os-warren

@os-warren os-warren commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14456

The engine half of #13681's visibility rider. loop { body: [ try_catch { try, catch } ] } is the ruled containment spelling for a per-iteration failure that must not end the sweep (maintainer 2026-08-31, director batch #18, verbatim 「其他同意」; branch B selected by measurement — there is deliberately no loop.config.onIterationError key). Containment already worked. What did not exist was any way to see what it had contained.

The contract this populates was declared by PR #14452 and is already on main: FlowRunSummary.failed, TryCatchErrorValueSchema, and the iteration / regionKind prose on ExecutionStepLogSchema. ⛔ No packages/spec file is touched by this PR — this fills declared slots, it does not open new ones.

The measurement this reproduces

#13681 comment 5478851960, on the real AutomationEngine: five rows, the third ownerless, notify failing on it. Contained correctly, and reported like this:

status=completed selected=5 acted=9 skipped=0

Five iterations ran, one row was lost, and nothing in that line, in the run row, or on $error said so. The failing step carried no iteration index; $error was { nodeId: 'guard', message: "…" } and named the row only because the message happened to echo the template. A sweep that lost two rows out of five was indistinguishable from one that lost none.

That fixture is now a committed test: packages/services/service-automation/src/builtin/contained-failure-visibility.test.ts.

What changed

1 · FlowRunSummary.failedsummarizeRun folds failed = Σ nodes[].failures. Computed in the loop that already walks nodes.values() to settle each node's status, rather than as a second accumulator in the step loop: two counters over one fact drift, one addition cannot, and the field is DECLARED as a fold ("stated as a fold so the run-level count can never disagree with the per-node breakdown"). The test asserts the identity directly, not just the number.

2 · Per node — no change, as the card specifies. Verified rather than assumed: run-summary.ts already does if (step.status === 'failure') node.failures += 1; per step. Nothing was added.

3 · Iteration through try_catchrunRegion. The card left this choice to the implementer, fenced on leaving parallel untouched. Chosen: forward the loop's iteration at the try/catch call site. Both options and why this one:

  • Rejected — teach the tagger to fill iteration on already-tagged steps. runRegion's tagger is shared by every container. parallel calls it with iteration: set to the BRANCH INDEX (its pre-existing overload, A parallel branch inside a loop body overloads the step record's iteration with the branch index — the enclosing loop iteration is lost, so a branch step cannot be attributed to its row #14414's subject). Under that change a try / catch region nested inside a parallel branch would start receiving the BRANCH index on iteration from parallel's own tagger — a change to what parallel writes, in exactly the territory the fence rules out.
  • Taken — forward at the call site. try-catch-node.ts adds iteration to the grouping it hands runRegion for both regions. runRegion and parallel-node.ts are byte-unchanged, and a parallel branch step still carries its branch index exactly as before. There is a pin for that: leaves 'parallel' branch tagging exactly as it was — the #14414 fence, which passes identically with and without this implementation (see the ablation below — it is one of the four that must NOT move).

The forwarded value comes from a new module, builtin/loop-frame.ts: the loop publishes { iteration, item, scope } for the duration of each body region, and a nested container reads it. AsyncLocalStorage (the primitive @objectstack/objectql already uses for its ambient transaction) rather than a field on the engine, because two parallel branches run their bodies concurrently over one engine and a stored "current iteration" would be read by whichever continuation resumed next. The frame also names the variable scope it belongs to and a reader must present a matching one — a region runs in the enclosing scope, a subflow / map child run gets a fresh Map, so a parent's row identity cannot leak into a child run's $error. That is pinned too.

4 · $error binds the row. The value bound to errorVariable is the declared TryCatchErrorValue, gaining iteration and the loop's current item inside a loop body and binding neither outside one. The test asserts the bound value safeParses under TryCatchErrorValueSchema, so the shape is the contract's and not this executor's invention.

⚠️ One honest wrinkle for the reviewer: the executor also binds code (#14419), which TryCatchErrorValueSchema does not declare. Dropping it would regress a catch region's ability to branch on {$error.code}, so it is spelled as an explicit TryCatchErrorValue & { code?: string } widening with a comment — loud and removable — rather than silently dropped or silently smuggled. The divergence is already filed as #14954 against the spec lane and is not addressed here; that issue remains open.

5 · failed= on the summary line. formatRunSummaryLine prints the token whenever the count is PRESENT, failed=0 included. It sits between skipped= and the optional unmeasured= / gate= tokens, so the always-present prefix stays greppable as one unit.

Read failed=0 precisely, because it is narrower than it looks: no node execution OF THIS RUN failed. The fold is Σ nodes[].failures over this run's own nodes, so a subflow child that CONTAINED failures of its own reports them on the child's summary and the parent still prints failed=0 — unlike acted, which does roll a child's totals up. That asymmetry is in the declaration, not in this implementation, and is filed as #15617. The narrowing is written into all three places a reader meets the claim: the failed row in flows.mdx, formatRunSummaryLine's comment, and the changeset.

⚠️ The convention this card names as the most losable one

failed absent means "not tracked" — it is NEVER defaulted to 0.

A run recorded before this change did not carry the count. Defaulting it to zero would tell an operator "nothing failed" about a run nobody measured — the same reasoning unmeasured carries three lines above it in the schema, and the reason both are .optional() there. So:

  • no migration, no default, no backfill;
  • a row persisted before this change parses with failed absent — pinned;
  • absent prints no failed= token at all, which is a different reading from failed=0;
  • summarizeRun always emits the count, 0 included, so the absent case belongs only to stored rows.

Persistence

The count rides in summary_json with the rest of the fold, and — the branch worth checking — it is kept when serializeSummaryBounded drops nodes / gates past the 16 KiB cap. That branch drops exactly the nodes[].failures this folds, so a compacted summary would otherwise go from "some rows failed" to silence. Pinned with a 600-node fixture.

It gets no column of its own on sys_automation_run, deliberately: the four existing count columns are there because the broken-sweep FILTER queries them, and this is a stored-surface change on an ADR-0103 engine-owned object. Whether it should have one is filed separately as #15606 (a finding, not addressed here — that issue remains open).

Also out of scope by construction: the fold is exactly Σ nodes[].failures, so a subflow child's contained failures stay in the child's own summary rather than rolling up the way acted does. That follows from the declared fold, so it is a property, not a gap.

Verification

Base 900334a56, final commit cb2bc181b (round 2; the engine work is unchanged since 56eeff22e — round 2 is prose and one .mdx).

run result
pnpm --filter @objectstack/service-automation test 107 files / 1290 tests passed
pnpm --filter @objectstack/service-automation typecheck green (tsc --noEmit + check:test-typecheck, 0 errors)
pnpm lint (eslint . --no-inline-config, whole repo) exit 0 — the full scan, not a narrowed one
derived gate family (scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack) 81 run · 74 exit 0 · 0 red on cb2bc181b (was 67/60/0 before the .mdx edit pulled in the docs family)
node scripts/check-adr-0087-registration.mjs --base origin/main --head cb2bc181b exit 0 — the REAL invocation, not --self-test
CJS entry loads (require('dist/index.js')) OK — probed because loop-frame.ts adds a node:async_hooks import

The 7 non-zero gate exits are all NOT MEASURED, not red, and each says so itself: check-partof-closing-keyword / check-single-claim-paths print NOT WIRED — … judged nothing; check-test-completeness, check-half-states and check:dual-build-cjs-loads print PREREQUISITE NOT MET (a saved turbo log, a repo-scoped GitHub read this container is refused, and a full pnpm build respectively); check:published-readme-exports wants the same full build; pr-labels.mjs printed its usage. None is a verdict about this diff. Exit codes were captured by redirect, never across a pipe.

check:engine-double-contract did red once, honestly: the new run-history pins added two more unguarded engine doubles to run-summary.test.ts, over a shrink-only baseline of 5. Fixed the way the gate itself advises — by reusing the double the file already has (one shared recordingRunStore() factory for the three recordTerminal pins) rather than raising the ledger. The baseline is untouched and the file's population is still 5.

Review round 2 — docs only, no engine change

Contract review at tier returned CHANGES REQUIRED with three prose items; the engine work passed unchanged.

  • content/docs/automation/flows.mdx:875 was stale. It prints the run summary line as literal log output, directly above the table row that documents failed, and this PR's own token had been added to the test pin (run-summary.test.ts) while the doc's copy of the same line was left alone. It now reads … skipped=30 failed=0 gate=…. content/docs/releases/v17.mdx:1607 carries the same line and is deliberately not touched: release notes are a record of what that release printed, and are never edited from a code PR.
  • The failed=0 reading is narrowed to what was measured, in the doc row, the formatRunSummaryLine comment and the changeset — see item 5 above.
  • Two new issues referenced in Out of scope below.

Re-verified on cb2bc181b: derived gate family 81 run · 74 exit 0 · 0 red (the .mdx edit pulls in 14 docs gates the earlier rounds never touched — check:doc-authoring, check:doc-anchors, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:corpus-claim-drift, check:published-readme-links, check:role-word, check:vendor-version-stamps, check:skill-identifier-liveness — all exit 0, as do both scripts/docs-audit/ members and all four changeset gates); package suite 107 files / 1290 tests passed; typecheck green; pnpm lint (whole repo) exit 0; ADR-0087 real invocation exit 0. The same 7 NOT-MEASURED exits as before, unchanged.

Ablation

Reverted the three implementation files to the base commit and deleted loop-frame.ts, with the mutation confirmed on disk by grepping both the injected and the removed text (failed += node.failures → 0, if (summary.unmeasured) parts.push back at 1, loopFrame → 0, runInLoopIteration → 0, loop-frame.ts ABSENT):

Test Files  2 failed (2)
     Tests  12 failed | 43 passed (55)

No rebuild step is involved and none is claimed: every mutated file is imported by the tests through a relative specifier inside the same package, so vitest reads the source, never a stale dist/. The 12 reds prove it.

Four of the new pins deliberately stayed green through the ablation, and that is the correct reading rather than a weakness — they pin what must NOT change: parallel branch tagging, no binding outside a loop, no leak into a subflow child, and an older summary printing no failed=.

Restored under a trap with absolute paths, then proven — git hash-object on each of the four files equals its HEAD blob, and git diff HEAD is empty.

Premise check

Each of the PM's five measurements was re-run on this base rather than inherited:

  • A1 confirmedFlowRunSummarySchema already declares failed, documented as the fold, with the absent-is-not-zero convention written out. packages/spec is untouched.
  • A2 confirmed — the per-node failures increment already exists; nothing was added for item 2.
  • A3 confirmedTryCatchErrorValueSchema imports cleanly from @objectstack/spec/automation.
  • A4 confirmed, and re-resolvedsummarizeRun / formatRunSummaryLine live in run-summary.ts (called from engine.ts and re-exported from index.ts). No line number from the card or its refs was built on.
  • A5 confirmedtry-catch-node.ts was clean at the base.

Out of scope, not widened into

#14414 (parallel-in-loop iteration attribution) · ⛔ #13803 (acted: 0 after a mid-loop death) · ⛔ #14394 (the lint and docs limbs) · ⛔ packages/spec.

Four issues this work touches and does not address — each open, none of them addressed by this diff:

Clause-② applies. The governance-tier contract review has run and returned CHANGES REQUIRED on three prose items, all addressed above; the PR stays a draft and landing is the PM's. The public type surface does not widen — spec already declares every field — but what a shipped surface REPORTS does: a consumer reading summary.failed starts getting a number where it got undefined, and formatRunSummaryLine emits a token on a line that previously carried none. skip-changeset does not apply: @objectstack/service-automation publishes, and a changeset is included.

…ontract (#14456)

`loop { body: [ try_catch { try, catch } ] }` contained a per-iteration
failure but reported nothing about it: no run-level count, no iteration on
the failing step, no row identity on `$error`.

- `summarizeRun` folds `failed = Sigma nodes[].failures` over the array it
  publishes, so the run-level count cannot disagree with the breakdown.
- `formatRunSummaryLine` prints `failed=N` whenever present, `failed=0`
  included; absent (an older row) prints nothing. Absent is "not tracked",
  never zero — no migration, no default.
- `try_catch` forwards the enclosing loop's iteration into `runRegion`'s
  grouping for both regions, leaving the tagger and `parallel` untouched.
- `$error` is the declared `TryCatchErrorValue`, gaining `iteration` and
  `item` inside a loop body and binding neither outside one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ory pins (#14456)

`check:engine-double-contract` counts unguarded doubles PER FILE against a
shrink-only baseline. Three copies of the same four-member fake would have
been two new ledger rows for one fact; one shared factory keeps the file's
population at the 5 the baseline already records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… `code` widening

Refs #14456

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ba426b0f091b8d5901acf10bb8e649b514349f49packageMentionDocs.

Which tree this was computed on

This run read content/docs from 026f57374ae4e1cf1c75d8961b66efecbd00489c — the merge of head cb2bc181bee85fd78f87498695c7c3d523072d2e into base ba426b0f091b8d5901acf10bb8e649b514349f49, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 026f57374ae4e1cf1c75d8961b66efecbd00489c && git checkout 026f57374ae4e1cf1c75d8961b66efecbd00489c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba426b0f091b8d5901acf10bb8e649b514349f49 cb2bc181bee85fd78f87498695c7c3d523072d2e && git checkout -B drift-repro ba426b0f091b8d5901acf10bb8e649b514349f49 && git merge --no-ff cb2bc181bee85fd78f87498695c7c3d523072d2e

node scripts/docs-audit/affected-docs.mjs --json ba426b0f091b8d5901acf10bb8e649b514349f49

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review (Clause-②) — PR #15609 @ 56eeff22e

FUSE: serving model claude-fable-5-1; CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:8659) — exact match, review valid.

VERDICT: CHANGES REQUIRED — the engine work passes every attack below; what is owed is docs-only (one stale literal line this PR's own pin contradicts, one over-broad reading of failed=0) plus two issues to file for defects found one layer past the diff. No code change is required.

Reviewed in a dedicated worktree at 56eeff22e (git status 0 lines before and after every step; pnpm install exit 0). Three-dot origin/main...HEAD: 9 files / +684 / −19, as the PM measured. Clean-tree gates, exit codes by redirect: package suite 107 files / 1290 tests, exit 0; typecheck exit 0; check-engine-double-contract.mjs --self-test exit 0 and the real run exit 0; require('dist/index.cjs') exit 0 and import('dist/index.js') exit 0 after a real pnpm --filter …service-automation... build (exit 0).

Acceptance reproduction — confirmed

My own flow, not the PR's fixture, spelled exactly as the card: loop { body: [ try_catch { try: [notify], catch: [assignment] } ] } with nothing else in catch ($error read from the enclosing scope by a node after the loop — the binding lives in the shared Map). Real AutomationEngine, 5 rows, third ownerless:

  • run completed; loop-body iterations [0,1,2,3,4]; summary line status=completed selected=5 acted=4 skipped=0 failed=1
  • the catch region's assignment step: { parentNodeId:'guard', regionKind:'catch', iteration:2 }; the failing notify step: { regionKind:'try', iteration:2 }
  • $error = {"nodeId":"guard","message":"Node 'notify' failed: …(c3)","iteration":2,"item":{"id":"c3","owner":null}}, TryCatchErrorValueSchema.safeParse ok
  • two-caught variant → failed=2.

Item 1 — the unmeasured convention — confirmed

Hunted every path where an absent count could become 0:

  • Zod: execution.zod.ts:263 is .optional() with no .default (read, not grepped).
  • Producer: summarizeRun always emits (run-summary.ts:153); the line guard is !== undefined (run-summary.ts:206), so failed=0 prints and absent prints nothing.
  • Persistence, both branches of serializeSummaryBounded (suspended-run-store.ts:849-862): the full branch is JSON.stringify(summary); the compacted branch spreads failed: summary.failed, and undefined stringifies away.
  • Rehydration: parseJson(row.summary_json, undefined) at suspended-run-store.ts:589 is a raw JSON.parse, no merge. Driven through the real listHistory read path with three rows: older (no key) → 'failed' in summary === false, line …skipped=0 with no token; failed:0…skipped=0 failed=0; compacted older (detailOmitted:true, no key) → absent.
  • Log meta engine.ts:6581 mirrors the unmeasured line above it; the emit site only ever logs a freshly folded summary.
  • grep -rn 'failed ??\|failed ||' over the package's non-test src: zero hits for a summary (notify-node.ts:417 failed: result.failed is the unrelated hit that proves the grep ran).

Item 2 — the fold and its stated bound — confirmed against the declared formula; spec inconsistency to file (see N2)

failed += node.failures in the loop that settles node.status (run-summary.ts:135-145), one accumulator over the published array; my acceptance run asserts summary.failed === Σ nodes[].failures and guard: {runs:5, failures:0, status:'success'} / notify: {runs:5, failures:1}. The subflow bound the PR states is real and I measured it: parent loop { subflow(child) }, child containing its own failure → parent line status=completed selected=5 acted=4 skipped=0 failed=0 while the five child summaries carry failed=[0,0,0,0,1]. acted rolled up, failed did not. That follows from the declaration: subflow-node.ts (blob 8e069f46, identical to main) rolls up only selected/acted/unmeasuredEffect through ExecutionStepMetrics, which declares no failure slot, so Σ nodes[].failures cannot see a child's contained failure without a spec change. When the child fails rather than contains, the parent's subflow node failure is counted (call: {runs:5, failures:1}, parent failed=1) — consistent. Not a defect in this PR; the declaration is what disagrees with itself (execution.zod.ts:218-222 says a subflow rolls its child's totals up and the summary answers "what did this run cause"; :251-264 says failed is the node fold).

Item 3 — call-site forwarding, #14414 fence — confirmed

  • Byte-unchanged by blob id: parallel-node.ts 68c475075c… on both origin/main and HEAD. engine.ts differs (c5f075e2…d765ab2e…) by exactly 4 inserted lines at 6575-6581 (log meta); runRegion's tagger at engine.ts:7848-7862 is not in the diff.
  • Branch index preserved under the new tagging: try_catch inside a parallel branch inside a loop → branch steps ga,0 noop,1 ×5 (regionKind:'parallel-branch'), while the try/catch region's own steps carry the loop index (notify,try,2,failure / capA,catch,2) and $error.iteration===2.
  • The fence pin does discriminate: mutating the tagger to also write iteration onto already-tagged steps (} else if (grouping.iteration !== undefined) { step.iteration = grouping.iteration; }, injected=1 on disk) turned exactly leaves 'parallel' branch tagging exactly as it was red — because it asserts every(iteration===0) across 5 loop rows, a loop-index bleed shows as 1..4.

Item 4 — loop-frame.ts / AsyncLocalStorageconfirmed

Scope identity is sound because engine.execute always allocates new Map() (engine.ts:8465; resume rebuilds from persisted at :5298), so a child's scope is never === its parent's. Leak attempts, all on the real engine:

  • subflow child with its own loop, invoked from a parent loop → child binds its own iteration:1 / item:k1 on all five calls, never the parent's 0..4;
  • map child inside a parent loop → $error carries neither key;
  • nested loops → inner wins ([2,'c3'] for both outer groups);
  • two loops in two parallel branches, bodies interleaved with 3/5 ms delays, every row failing → 5 catches, each $error consistent with its own branch and row.
    Removing the scope check (return frame;) turns the subflow-leak pin red. runRegion refuses durable pause inside a region (engine.ts:7869-7872), so no resume path re-enters a body without its frame. node:async_hooks is present in both dist entries (cjs 2 / esm 1 hits) and both entries load; the package already ships node:crypto (http-nodes.ts:6), is node-only (engines.node >=22, no browser field), so the builtin is not a new class of dependency.

Item 5 — $error binding and the code widening — confirmed

Outside a loop: neither key (dev pin; mutation M2 below proves the pin distinguishes absent from key-present-undefined). Inside a loop with a coded failure: {"nodeId":"g","message":…,"code":"DUPLICATE_RECORD","iteration":2,"item":{…}}, safeParse ok — and parsed.data keys are nodeId, message, iteration, item: a consumer normalising through the schema loses code, which is exactly #14954's content. The widening is a single local annotation at the binding site (try-catch-node.ts:235) with a comment naming #14954 — one-line removal when the schema gains code, not a second contract.

Item 6 — the double-contract red — confirmed

Ledger blob scripts/engine-double-contract.baseline.json = f4bfac2a3… on both origin/main and HEAD; gate self-test and real run both exit 0 with the ledger untouched; the gate's per-file output shows run-summary.test.ts pinned [delete] / pinned [findOne]. I did not re-derive the "5"; the literal const engine: any = { count in that file is 3 on main and 3 on HEAD.

Item 7 — the four green pins — confirmed, each discriminates

mutation (injected=1 on disk, anchor=0 after) pin turned red
M1 failed=${summary.failed ?? 0} prints NOTHING for a summary recorded before the count existed
M2 iteration: loopFrame?.iteration, item: loopFrame?.item binds NEITHER outside a loop and the subflow-leak pin
M3 currentLoopIterationreturn frame; does not leak a parent run's row identity into a subflow child
M4 tagger writes iteration onto already-tagged steps leaves 'parallel' branch tagging exactly as it was
Each file restored with git checkout HEAD -- <abs path>; git hash-object equals the HEAD blob for all four (9917dd08…, 04359f33…, a10242cb…, d765ab2e…).

Anything new

N1 — defect, this PR (docs): content/docs/automation/flows.mdx:875 prints the run summary line as literal log output: …selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30. After this PR that run prints …skipped=30 failed=0 gate=… — this PR updated its own pin to exactly that (run-summary.test.ts:150-156) and left the doc's copy of the same line stale, directly above the table row that documents failed and says "Absent on a run that did not track it". content/docs/automation/ is a hand-written tree (AGENTS.md doc guardrails: only references/ and releases/ are off-limits in a code PR), and the docs-drift bot on this PR says itself it cannot see this page.

N2 — spec-lane inconsistency, not this PR's code: per item 2, failed=0 on a parent that delegates rows to a subflow reads "nothing failed" (the changeset's words) while the children lost rows. The two spec paragraphs conflict for that case; the resolution (a failure slot on ExecutionStepMetrics that subflow/map roll up, or an explicit carve-out on failed) is a domain:spec decision. Third in the series after #14954 / #15606.

N3 — pre-existing engine defect, out of this PR's surface (file it): a map node inside a loop body runs its collection once, on the first iteration only. map-node.ts keeps ${node.id}.$mapState in the shared scope and never deletes it after "All items done" (variables.set(stateKey, state) ~line 212; zero variables.delete(stateKey) hits, control: the sibling $mapItemDone is deleted at lines 132-133), so iterations 2..n see started === collection.length and run nothing while the step reports success. Measured: 5 iterations × 2 items → 2 child runs, map step success on all five, run completed, failed=0. Silent partial work is the class #14456 exists to expose, and this one is invisible even to the new counter.

N4 — observation, #14414's: under a parallel branch inside a loop, the try/catch region's steps now carry the loop index while their container's own step carries the branch index; regionKind disambiguates. Unchanged writes by parallel, but it is the overload #14414 owns.

N5 — observation: $error.item is collection[i] captured at publish time; if the try region reassigns the iterator variable before failing, item still names the original row (measured: [2,'c3'] after an assignment overwrote currentCase). Arguably the better answer; the spec text says "the iteratorVariable value" — a one-word clarification at most.

Changes required (no code)

  1. content/docs/automation/flows.mdx:875 — bring the literal line to what the run now prints: … skipped=30 failed=0 gate=check_stalled->send_nudge:30, matching run-summary.test.ts:150-156.
  2. Narrow the "failed=0 is the reading 'nothing failed'" sentence (changeset, run-summary.ts:197-205 comment, and the failed row at flows.mdx) to what item 2 measured: no node execution of this run failed — a subflow child's contained failures stay on the child's row. Cite the N2 issue.
  3. File N2 (domain:spec) and N3 (domain:services, map-in-loop) as issues and reference them from the PR body's "Out of scope" list, alongside spec: TryCatchErrorValueSchema silently strips the code key the engine now binds — the declared "ONE shape" and the runtime shape have diverged (#14419 follow-up) #14954 / [finding] service-automation: FlowRunSummary.failed has no queryable column on sys_automation_run, so "which runs lost rows?" is a summary_json scan #15606.

Generated by Claude Code

…ed=0` claims (#14456)

Contract review, three docs-only changes; no engine change.

- `flows.mdx` printed the summary line as literal log output and was left
  stale by this PR's own token: the run it shows now prints
  `skipped=30 failed=0 gate=…`, and the line sits directly above the table
  row documenting `failed`.
- Narrowed the reading of `failed=0` in three places — the doc's `failed`
  row, `formatRunSummaryLine`'s comment and the changeset — from "nothing
  failed" to what was measured: no node execution OF THIS RUN failed. A
  `subflow` child's contained failures stay on the child's summary rather
  than rolling up the way `acted` does. Cites #15617, where the
  declaration's two paragraphs are being reconciled.

`content/docs/releases/v17.mdx` carries the same line and is deliberately
NOT touched: release notes are a record of what that release printed, and
are never edited from a code PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

Copy link
Copy Markdown
Collaborator Author

Contract review (Clause-②) — round 2 — PR #15609 @ cb2bc181b

FUSE: serving model claude-fable-5-1; CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:8659) — exact match, review valid.

VERDICT: PASS. The three docs-only items from round 1 (comment 5547800535) are done, the release-notes call is right, and the PR is still a draft. One new thing was found (four more historical copies of the summary line, in changesets-generated CHANGELOGs); the disposition I chose is PASS with it noted — it makes no published statement false and the same "do not touch" judgement covers it.

Reviewed in a dedicated worktree at cb2bc181b (git status 0 lines). The round-2 delta 56eeff22e..cb2bc181b is one commit, 3 files, +20/−7, as the PM measured. run-summary.ts: every +/ line stripped by a comment-prefix filter leaves 0 lines (grep exit 1; control: the same filter over the .mdx diff leaves 4). run-summary.test.ts is byte-identical between the two heads (git diff --quiet exit 0), so the pin compared below is the one round 1 measured green — nothing executable changed and I re-ran nothing, deliberately.

Item 1 — flows.mdx:875 vs the pin — confirmed

The pin literal (run-summary.test.ts:151-157, two concatenated strings) reconstructed and compared to the doc line token by token: same 11 token keys in the same order, and the span selected=30 acted=0 skipped=30 failed=0 gate= is byte-equal in both. The three tokens that differ — run=run_a1b2/run_1, durationMs=142/42, gate=check_stalled->send_nudge:30/gate->nudge:30 — are the doc's illustrative fixture values, present unchanged in the merge-base copy (git show 900334a56:content/docs/automation/flows.mdx, line 875), so what round 1 asked for (… skipped=30 failed=0 gate=…) is exactly what moved.

Item 2 — the narrowed failed=0 reading, three places, each citing #15617confirmed

The sentence is true as round 1 measured it (item 2 of 5547800535: parent loop { subflow(child) } → parent failed=0, children failed=[0,0,0,0,1]; a child that fails rather than contains → call: {runs:5, failures:1}, parent failed=1). The new comment states both the measurement and the control exactly as measured; not re-derived here. #15617 exists and is open; its title is the two-paragraphs disagreement the prose describes.

Item 3 — "Out of scope" names #15617 and #15616 beside #14954 / #15606confirmed

PR body, section "Out of scope, not widened into": four bullets, each saying what the issue is and why it is not addressed here. Cross-checked against the live issues: all four state: open; #15617 = the subflow parent declaration conflict (matches the bullet), #15616 = map-in-loop runs its collection once and completes green (matches, including the "silent partial work the counter cannot see" framing), #14954 = the code key the schema strips, #15606 = no failed column on sys_automation_run.

Release notes — the right call, and stated, not silent — confirmed

git diff --name-only 900334a56 cb2bc181b -- content/docs/releases/0 (control: -- content/docs/automation/ → 1, flows.mdx); git diff --quiet … -- content/docs/releases/v17.mdx exit 0. AGENTS.md:730 makes content/docs/releases/ RELEASE-OWNED: "Never edit in a code PR … Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes." v17.mdx:1607 is not even a factual error — it records what v17.0 printed, and failed= did not exist then. The omission is stated in the PR body (section "Review round 2") and in the commit message of cb2bc181b (third paragraph). Right call.

The two refusals to overclaim — confirmed

  • No ablation this round. The PR body's only ablation section is round 1's engine ablation; the "Review round 2" paragraph claims gates / suite / typecheck / lint / ADR-0087 re-runs and no ablation. Where the dev "said so plainly" was the dev report, which I cannot see — NOT MEASURED as to phrasing; what is measured is that the PR does not overclaim one, and an ablation of a comment-only diff could not discriminate anyway.
  • Still a draft. pull_request_read get"draft": true, state: open, mergeable_state: blocked, updated_at 2026-09-05T00:12:25Z (after the round-2 push).

Anything new

N1 — noted, PASS. A wider grep (acted=[0-9]+ skipped=[0-9]+, whole repo minus node_modules/dist/.git) finds four more copies of the stale line beyond v17.mdx:1607: packages/spec/CHANGELOG.md:27034, :66515, packages/services/service-automation/CHANGELOG.md:3328, :9607. These are changesets-generated (.changeset/config.json@changesets/cli/changelog; the service-automation copy sits under ## 17.0.0) — release records of the same class as v17.mdx, untouched by this PR (0 CHANGELOG.md in the diff). The dev's "exactly one more" is a docs-tree count; neither the PR body nor the commit message claims exclusivity (both say v17.mdx "carries the same line"), so nothing published is false, and the judgement is identical for them.

N2 — observation only. The TS comment writes Sigma nodes[].failures where the changeset writes Σ — presumably to keep the source ASCII; harmless.

Nothing to change. Landing is the PM's; not merged, not marked ready, branch untouched.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants